Ámbitos y funciones decoradoras

NOTA: Antes de realizar esta lección debes reiniciar Jupyter Notebook para vaciar la memoria.

Introducción

No cabe duda de que Python es un lenguaje flexible, y cuando trabajamos con funciones no es una excepción.

En Python, dentro de una función podemos definir otras funciones. Con la peculiaridad de que el ámbito de estas funciones se encuentre únicamente dentro de la función padre. Vamos a trabajar los ámbitos un poco más en profundidad:


In [1]:
def hola():
    
    def bienvenido():
        return "Hola!"
    
    return bienvenido

Si intentamos llamar a la función bienvenido...


In [3]:
bienvenido()


---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-3-f083d151b813> in <module>()
----> 1 bienvenido()

NameError: name 'bienvenido' is not defined

Como vemos nos da un error de que no existe. En cambio si intentamos ejecutar la función hola():


In [2]:
hola()


Out[2]:
<function __main__.hola.<locals>.bienvenido>

Se devuelve la función bienvenido, y podemos apreciar dentro de su definición que existe un espacio llamado locals, el cual hace referencia al ámbito local que abarca la función.

Ámbito local y global

Si utilizamos una función reservada locals() obtendremos un diccionario con todas las definiciones dentro del espacio local del bloque en el que estamos:


In [2]:
def hola():
    
    def bienvenido():
        return "Hola!"
    
    print( locals() )  # Mostramos el ámbito local

hola()


{'bienvenido': <function hola.<locals>.bienvenido at 0x000001F867E88C80>}

Como vemos se nos muestra un diccionario, aquí encontraremos la función bienvenido().

Podríamos añadir algo más:


In [5]:
lista = [1,2,3]

def hola():
    
    numero = 50
    
    def bienvenido():
        return "Hola!"
    
    print( locals() )  # Mostramos el ámbito local

hola()


{'bienvenido': <function hola.<locals>.bienvenido at 0x000001F867E88950>, 'numero': 50}

Como podemos observar, ahora además de la función tenemos una clave con el número y el valor 50. Sin embargo no encontramos la lista, pues esta se encuentra fuera del ámbito local. De hecho se encuentra en el ámbito global, el cual podemos mostrar con la función reservada globals():


In [1]:
# Antes de ejecutar este bloque reinicia el Notebook para vaciar la memoria.
lista = [1,2,3]

def hola():
    
    numero = 50
    
    def bienvenido():
        return "Hola!"
    
    print( globals() )  # Mostramos el ámbito global

hola()


{'__name__': '__main__', '_oh': {}, '__doc__': 'Automatically created module for IPython interactive environment', 'lista': [1, 2, 3], '__builtin__': <module 'builtins' (built-in)>, 'In': ['', '# Antes de ejecutar este bloque reinicia el Notebook para vaciar la memoria.\nlista = [1,2,3]\n\ndef hola():\n    \n    numero = 50\n    \n    def bienvenido():\n        return "Hola!"\n    \n    print( globals() )  # Mostramos el ámbito global\n\nhola()'], '_ih': ['', '# Antes de ejecutar este bloque reinicia el Notebook para vaciar la memoria.\nlista = [1,2,3]\n\ndef hola():\n    \n    numero = 50\n    \n    def bienvenido():\n        return "Hola!"\n    \n    print( globals() )  # Mostramos el ámbito global\n\nhola()'], '__loader__': None, '__builtins__': <module 'builtins' (built-in)>, '_dh': ['C:\\CursoPython\\Fase 4 - Temas avanzados\\Tema 15 - Funcionalidades avanzadas\\Apuntes'], 'get_ipython': <bound method InteractiveShell.get_ipython of <ipykernel.zmqshell.ZMQInteractiveShell object at 0x00000243D11F5E80>>, 'hola': <function hola at 0x00000243D1B58C80>, '_sh': <module 'IPython.core.shadowns' from 'C:\\Users\\Hector\\Anaconda3\\lib\\site-packages\\IPython\\core\\shadowns.py'>, '_': '', '_ii': '', 'Out': {}, '__package__': None, '___': '', '_iii': '', '_i': '', '__spec__': None, 'exit': <IPython.core.autocall.ZMQExitAutocall object at 0x00000243D1A92B70>, 'quit': <IPython.core.autocall.ZMQExitAutocall object at 0x00000243D1A92B70>, '_i1': '# Antes de ejecutar este bloque reinicia el Notebook para vaciar la memoria.\nlista = [1,2,3]\n\ndef hola():\n    \n    numero = 50\n    \n    def bienvenido():\n        return "Hola!"\n    \n    print( globals() )  # Mostramos el ámbito global\n\nhola()', '__': ''}

Tampoco es necesario que nos paremos a analizar el contenido, pero como podemos observar, desde el ámbito global tenemos acceso a muchas más definiciones porque engloba a su vez todas las de sus bloques padres.

Si mostramos únicamente las claves del diccionario globals(), quizá sería más entendible:


In [5]:
globals().keys()


Out[5]:
dict_keys(['_i4', '__name__', '_oh', '__doc__', 'lista', '__builtin__', 'In', '_i5', '_ih', '__loader__', '__builtins__', '_dh', 'get_ipython', 'hola', '_sh', '_', '_ii', 'Out', '__package__', '___', '_iii', '_i', '__spec__', 'exit', '_i3', 'quit', '_i1', '_i2', '__'])

Ahora si buscamos bien encontraremos la clave lista, la cual hace referencia a la variable declarada fuera de la función. Incluso podríamos acceder a ella como si fuera un diccionario normal:


In [9]:
globals()['lista']  # Desde la función globals


[1, 2, 3]

In [12]:
lista  # Forma tradicional


Out[12]:
[1, 2, 3]

Funciones como variables

Volviendo a nuestra función hola(), ahora sabemos que si la ejecutamos, en realidad estamos accediendo a su función local bienvenido(), pero eso no significa que la ejecutamos, sólo estamos haciendo referencia a ella.

Esa es la razón de que se devuelva su definición y no el resultado de su ejecución:


In [13]:
def hola():
    
    def bienvenido():
        return "Hola!"
    
    return bienvenido

hola()


Out[13]:
<function __main__.hola.<locals>.bienvenido>

Por muy raro que parezca, podríamos ejecutarla llamando una segunda vez al paréntesis. La primera para hola() y la segunda para bienvenido():


In [15]:
hola()()


Out[15]:
'Hola!'

Como es realmente extraño, normalmente lo que hacemos es asignar la función a una variable y ejecutarla como si fuera una nueva función:


In [16]:
bienvenido = hola()
bienvenido()


Out[16]:
'Hola!'

A diferencia de las colecciones y los objetos, donde las copias se utilizaban como accesos directos, las copias de las funciones son independientes y aunque borrásemos la original, la nueva copia seguiría existiendo:


In [17]:
del(hola)

bienvenido()


Out[17]:
'Hola!'

Funciones como argumentos

Si ya era extraño ejecutar funciones anidadas, todavía es más extraño el concepto de enviar una función como argumento de otra función, sin embargo gracias a la flexibilidad de Python es posible hacerlo:


In [23]:
def hola():
    return "Hola!"

def test(funcion):
    print( funcion() )
    
test(hola)


Hola Mundo

Quizá en este momento no se ocurren muchas utilidades para esta funcionalidad, pero creedme que es realmente útil cuando queremos extender funciones ya existentes sin modificarlas. De ahí que este proceso se conozca como un decorador, y de ahí pasamos directamente a las funciones decoradoras.

Funciones decoradoras

Una función decoradora es una función que envuelve la ejecución de otra función y permite extender su comportamiento. Están pensadas para reutilazarlas gracias a una sintaxis de ejecución mucho más simple.

Imaginaros estas dos funciones sencillas:


In [7]:
def hola():
    print("Hola!")

def adios():
    print("Adiós!")

Y queremos queremos crear un decorador para monitorizar cuando se ejecutan las dos funciones, avisando antes y después.

Para crear una función decoradora tenemos que recibir la función a ejecutar, y envolver su ejecución con el código a extender:


In [8]:
def monitorizar(funcion):

    def decorar():
        print("\t* Se está apunto de ejecutar la función:", funcion.__name__)
        
        funcion()
        
        print("\t* Se ha finalizado de ejecutar la función:", funcion.__name__)

    return decorar

Ahora para realizar la monitorización deberíamos llamar al monitor ejecutando la función enviada y devuelta:


In [9]:
monitorizar(hola)()


	* Se está apunto de ejecutar la función: hola
Hola!
	* Se ha finalizado de ejecutar la función: hola

Sin embargo esto no es muy cómodo, y ahí es cuando aparece la sintaxis que nos permite configurar una función decoradora en una función normal:


In [10]:
@monitorizar
def hola():
    print("Hola!")

@monitorizar
def adios():
    print("Adiós!")

Una vez configurada la función decoradora, al utilizar las funciones se ejecutarán automáticamente dentro de la función decoradora:


In [11]:
hola()
print()
adios()


	* Se está apunto de ejecutar la función: hola
Hola!
	* Se ha finalizado de ejecutar la función: hola

	* Se está apunto de ejecutar la función: adios
Adiós!
	* Se ha finalizado de ejecutar la función: adios

Pasando argumentos al decorador


In [12]:
def monitorizar_args(funcion):

    def decorar(*args,**kwargs):
        print("\t* Se está apunto de ejecutar la función:", funcion.__name__)
        funcion(*args,**kwargs)
        print("\t* Se ha finalizado de ejecutar la función:", funcion.__name__)

    return decorar

@monitorizar_args
def hola(nombre):
    print("Hola {}!".format(nombre))

@monitorizar_args
def adios(nombre):
    print("Adiós {}!".format(nombre))
    
hola("Héctor")
print()
adios("Héctor")


	* Se está apunto de ejecutar la función: hola
Hola Héctor!
	* Se ha finalizado de ejecutar la función: hola

	* Se está apunto de ejecutar la función: adios
Adiós Héctor!
	* Se ha finalizado de ejecutar la función: adios

Perfecto! Ahora ya sabes qué son las funciones decoradoras y cómo utilizar el símbolo @ para automatizar su ejecución. Estas funciones se utilizan mucho cuando trabajamos con Frameworks Web como Django, así que seguro te harán servicio si tienes pensado aprender a utilizarlo.